5.1 协程与调度器
Go
语言的协程是它的一大特性,通过协程可以快速实现并发,提高了开发者的工作效率。
本节我们将深入探讨协程的底层实现原理以及调度实现,我们将了解到并发到底是怎么实现的、多个协程是怎么配合的。
本节我们将以:服务员点餐的例子进行并行、并发、协程及调度器的讲解。
本节代码存放目录为 lesson13
协程的实现原理
什么是并行?
并行也就是一起执行,指的是多个任务在同一时间一起执行。
也就是说多个服务员同时为多个客户点餐,这显然效率是很高的,但是缺点就是需要的服务员数量比较多。
什么是并发?
并发就是多个任务在同一时间段内交替执行,比如说先执行一下任务A
,再去执行一下任务B
,之后回来继续执行任务A
。
也就是同一个服务员先为A
点餐,在A
考虑的过程中再去为B
点餐,同理当B
在思考下一个菜的时候服务员再去为A
点餐。这种方式在服务员数量有限的情况下显然是效率最高的。
协程的实现方式
Go
语言中协程使用的就是并发的方式,也就是多个任务在同一时间交替的执行。
一个服务员我们可以理解为一个系统线程,Go
通过GMP
模型提高了服务员的工作效率,使得服务员可以在很多个客户之间交替点餐。
我们可以理解为这个服务员的效率特别高,他可以马上记录下A
的菜单,之后在A
思考的过程中可以马上记录下B
的菜单,在B
思考的过程中又可以快速记录下C
的菜单。
GMP模型及调度器
调度器
在Go
程序运行时,P
会从它维护的队列中选择一个Goroutine
,将其分配给M
执行。
如果一个Goroutine
阻塞了(例如等待 I/O
操作),P
会将这个 Goroutine
暂时挂起,并选择另一个Goroutine
继续执行。
P
之间可以通过工作窃取机制(Work Stealing
)来共享任务,从而提高 CPU
的利用率。
我们可以理解为,调度器就是餐厅经理,负责管理服务员的工作。经理根据顾客的需求和服务员的忙碌程度来分配任务。
如果某个服务员忙不过来,经理可以调派其他空闲的服务员来帮助完成工作。
如果某个顾客等待时间过长,经理会确保一个服务员优先处理这个顾客的需求。
需要注意的是,Go
语言使用的是协作式调度,也就是说协程会主动让出。比如说A
点完一个菜以后需要考虑,那么他会主动让服务员先去为别人点餐。
G(Goroutine)
G
也就是协程,它是实现并发的核心单位,包含了程序的栈、程序计数器、指向当前函数的指针,以及一些调度信息。
当一个Goroutine
被创建时,它会被分配到一个P
上,准备执行。
我们可以理解为,G
就是服务员的一个任务事项,比如:为A
客户点餐,A
客户坐在1
号桌,他们有3
个人,一共要点5
个菜,现在已经点了3
个了,A
客户还需要思考3
分钟。
M(Machine)
M
代表的就是内核线程,它是Go
程序运行时的实际执行载体,负责真正执行协程代码。
每个M
对应一个内核线程,M
负责执行P
中的Goroutine
。
我们可以理解为,M
就是一个服务员角色,每个M
代表一个可以实际执行任务的服务员,调度器将A
客户点餐的任务分配给服务员M
。
P(Processor)
P
代表的是处理器,它维护着一个Goroutine
队列。P
的数量对应于 CPU
的核数,P
负责分配和调度Goroutine
到M
上执行。
P
还负责协程之间的切换和调度,它决定了哪个Goroutine
在什么时候被执行。
我们可以理解为,P
就是餐厅的工作站,每个工作站有一个服务员队列。
如果某个服务员忙不过来,经理可能会让另一个工作站的服务员来帮助处理。这就是P
之间的“工作窃取”机制。
P的数量设置
P
一般默认使用的是主机的CPU
核数,我们可以通过下面的方法获取到当前的P
数量。
// 获取系统的 CPU 核心数
fmt.Println("Number of CPUs:", runtime.NumCPU())
// 获取当前的 GOMAXPROCS
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
我们可以在程序中主动设置P
的数量:
// 设置 GOMAXPROCS 为 2
runtime.GOMAXPROCS(2)
fmt.Println("Current GOMAXPROCS:", runtime.GOMAXPROCS(0))
也可以通过主机环境变量进行设置:
# 设置 GOMAXPROCS 为 4
export GOMAXPROCS=4
同时也可以在运行时指定:
GOMAXPROCS=4 go run lesson13.go
示例讲解
我们将P
手动设置为2
,那么P
分别为P1、P2
,分别具有M1、M2
,模拟三个客户端点餐,三个点餐任务为G1、G2、G3
。
示意如下所示:
+---------------+ +------------------+ +-------------------+
| Scheduler |------>| Processor |------>| Machine (M1) |
| (调度器) | | (P1 工作站) | | (M1 服务员) |
+---------------+ | | +-------------------+
| | +-----------+ |
| | | Goroutine | |
| | | (G1 顾客) | |
| | +-----------+ |
| | +-----------+ |
| | | Goroutine | |
| | | (G2 顾客) | |
| | +-----------+ |
| +------------------+
⤵️
+----------------+ +-------------------+
| Processor |------>| Machine (M2) |
| (P2 工作站) | | (M2 服务员) |
| | +-------------------+
| |
| +-----------+ |
| | Goroutine | |
| | (G3 顾客) | |
| +-----------+ |
+----------------+
我们可以通过下面的代码模拟:
var (
wg sync.WaitGroup
)
wg.Add(3)
// 顾客 1
go func() {
defer wg.Done()
fmt.Println("顾客 1 正在点餐")
}()
// 顾客 2
go func() {
defer wg.Done()
fmt.Println("顾客 2 正在点餐")
}()
// 顾客 3
go func() {
defer wg.Done()
fmt.Println("顾客 3 正在点餐")
}()
wg.Wait()
fmt.Println("所有顾客点餐完毕")
结果输出如下所示:
顾客 3 正在点餐
顾客 1 正在点餐
顾客 2 正在点餐
所有顾客点餐完毕
其实我们只需要知道一个核心概念:协程的执行效率和并发能力与主机的 CPU
数量有关联。Go
的协程是通过调度器在多个CPU
核心上交替执行,来实现并发处理的。
调度器的工作机制
时间片轮转
时间片轮转是一种调度算法,在这种算法中,调度器将CPU
时间分成固定长度的时间片。
每个协程都有一个有限的时间片,如果协程在时间片内没有完成,它将被挂起,调度器会切换到另一个协程执行。
简单理解就是:调度器设定了协程执行的时间片长度,当时间片结束后协程没有执行完成,那么就执行另外一个,按照这种规律不断的切换执行。
抢占式调度
调度器是抢占式的,这意味着调度器可以在适当的时候强制中断当前运行的协程,切换到另一个协程。这个机制避免了某些协程独占CPU
资源太长时间,确保了公平调度。
比如说A
协程占用了比较多大时间,这时候B
、C
协程就会等待较长时间,从某种意义上来说就并没有达到效果。
在这种情况下,调度器就会将A
暂停,之后去执行B
、C
,再之后再回来执行A
。
系统调用处理
当一个协程执行系统调用(例如 I/O
操作)时,它可能会阻塞。这时,M
会被阻塞,P
会尝试寻找其他可以执行的协程。
如果找不到,P
可能会尝试从其他P
那里偷取任务,以保持高效利用资源。
这一点与抢占式调度
其实是有一些类似的,执行I/O
操作、网络请求等操作,都是会耗时的,哪怕是几毫秒,也是可以做很多事情了。
所以调度器会切换执行其他协程,如果当前队列没有协程了,那么就从其他P
去拿一个,相当于帮其他P
或者说处理器分担压力。
全局运行队列
除了每个P
维护的本地队列外,还有一个全局队列,存放着一些尚未被分配到P
的Goroutine
。
当P
的本地队列空闲时,会尝试从全局队列中获取任务。
这一点与之前的两点其实都有相似之处,总的来说就是:不让处理器停下来,充分利用每一点资源。
协程的栈管理
协程栈是动态扩展的,初始大小通常为2KB
。当协程需要更多栈空间时,Go
运行时会自动扩展栈空间。这使得协程的内存消耗非常小,支持大量协程同时运行。
系统级的线程占用有的会在几兆,这一点上协程就有了很大的优势,主要就是源于Go
将线程拆分为了很多个协程,将粒度变小,通过调度算法不断的切换调用,从而实现并发。
小结
本节主要讲解了协程的原理、调度器以及GMP
模型,通过本节的学习我们可以对协程原理有一个大致的了解。
关于本节总结如下:
Go
通过并发的方式实现协程GMP
模型是实现并发的核心逻辑调度器通过高效的调度算法实现轻量级的协程